查看原文
其他

看雪·深信服 2021 KCTF 春季赛 | 第六题设计思路及解析

KCTF 看雪学院 2021-05-20

本题我们跟随主人公飞停一起,寻找宝剑。本题耗时2天,吸引了超过2千人围观,最终11支战队夺旗成功!恭喜!


出题团队简介


崇文路大专是一个隶属于重庆邮电大学信息安全协会的CTF战队,其主要目标是培养对信息安全感兴趣的学弟学妹们,带着新入门的成员一起参加比赛,致力于夺得各大比赛签到题一血。同时也是重庆邮电大学信息安全协会旗下的0xFA战队的附属战队,0xFA战队曾多次进入XCTF线下总决赛、安洵杯线下总决赛、国赛总决赛。



专家点评


比赛第六题是一个有趣的42*42棋盘游戏,使用了自己开发的混淆器进行混淆处理。选手需要编写代码去除混淆拿到核心算法代码再进行逆向。题目难度中等偏上,需要考验选手的逆向和编写脚本的能力。



赛题设计思路


题目设计了一个巧妙的数学问题,既考察了各位选手的代码审计能力,又对信息收集能力与编程解题能力有一定的要求。 首先程序会问36乘49等于多少,这是对题目算法内容的一种暗示:36×49=42×42。 接着程序会读取输入,输入不允许出现0-9、A-Z、"+-*/%="以外的其他字符。 然后程序会检查输入的长度是否为84,如果满足则把输入的字符两两一组以四十二进制的方式解析成42个整数,存放在数组里,其中这42个整数必须是按照严格递增的顺序被输入。 接下来,用一个42×42的二维数组来抽象一个42×42个格子的棋盘,然后将该二维数组一维化,将输入的整数数组中的每个整数当作下标,将对应下标的元素设置为true(两位的四十二进制刚好不会超过棋盘的大小)。 若某数组元素为true,则代表棋盘中该元素对应的格子里放了一颗棋子,反之则没有放任何东西。 接下来检查,是否每一行、每一列均只有一颗棋子(即:同一行/列中不能有多颗棋子,因为只可以放42颗棋子,所以刚好可以每行每列放一个)。 我规定某两颗棋子的连线称作它们的相对偏移线段(比如:第一行第一列的棋子和第二行第二列的棋子的相对偏移线段是一条长度为根号二、斜率为负一的线段),若两条相对偏移线段的长度和斜率均相等,则称它们相等。 后面的代码是检验是否任意两颗棋子之间的相对偏移线段均不相等(注:相对偏移线段没有“方向”这个概念,也就是说,“棋子A与棋子B的相对偏移线段”和“棋子B与棋子A的相对偏移线段”视作同一条相对偏移线段)。 若检验通过,则判断输入的开头14个字符是否与指定的字符串相等,换句话说我指定了棋盘上前7颗棋子的位置(按从左到右、从上到下的顺序)。 若检验全通过,则输出成功,否则输出错误。 程序采用Release x64模式编译,所用编译器为Visual Studio 2019可选安装的LLVM - clang-cl(版本为10.0),设置了生成调试信息:否、优化:已禁用、启用内部函数:否、优选大小或速度:均不,编译环境为Windows10 2004。 由于题目要求不允许使用第三方壳或VM,我认为ollvm或许会被认定为第三方,因此没有采用ollvm方式编译,题目难度稍有降低。

原创混淆器




本次比赛出题使用了我自己开发的“COP”混淆器,COP可以打开并解析一个PE文件,针对32/64位分别进行处理。 COP要求所给文件必须具有.text段、.reloc段,且不含有花指令。 COP使用Capstone解析出程序文件的所有汇编指令,将指令进行混淆,并且随机打乱,膨胀后的指令无法塞进原.text段,因此将多余的部分放入新的.cop段中。 顺序被完全打乱的指令需要用某种方式连接它们,让它们顺序执行,我将非跳转指令用“call+pop+add/xor+push+ret”的方式连接,将跳转指令进行了不同程度的变形。 此外,COP还将程序所有基于相对偏移的指令进行了修复,通过重定位表将所有基于绝对地址的命令进行了修复,对64位程序的基于rip寻址的指令进行了修复。 COP自行维护了一个独立的重定位表,混淆后仍可以开启地址随机化。 为了提高混淆强度,对64位程序还将清空其.pdata段的内容。 COP还支持嵌套混淆(简称套娃),进一步提高混淆强度。 用户可以选择保留.reloc段或者去除,去除以后会进一步提高混淆强度,但不支持继续嵌套混淆,且会关闭地址随机化。 我使用COP将编译出的程序进行了2次混淆,得到最终附件,考察了各位选手编写脚本的能力。

解题思路




拿到的附件使用IDA打开,发现有非常多的重复的代码,且动态调试的时候有效指令出现的频率极低,因此可以考虑使用脚本对文件进行初步处理。  先拿到call后面的pop rax的地址,提取xor的第二个操作数,即可计算出下一条指令的位置。 计算相对偏移,将第一个push rax改成jmp到下一条指令。 这里比较方便的是使用IDAPython编写脚本,因为IDA提供了很多有用的API。 如此操作完以后,可以发现还嵌套了一层混淆。  这里可以采用的方法很多,我采用的顺序遍历+特征匹配的方式定位被jmp连起来的代码碎块,若一系列遍历下去发现指令与模式匹配,则认为它是一个混淆指令块,取pop rax的地址和add的第二个操作数(内层是add,外层是xor),计算下一条指令的位置,并把匹配的指令序列的第一个指令改成jmp,可以把代码还原成由jmp连接起来的零碎指令。  再用IDA打开去混淆后的程序,从字符串的交叉引用定位主函数  但是可以看到可能因为我的去混淆代码写得不是很好,IDA的F5插件仍然无能为力,这里可以考虑使用Capstone或者IDA自带的API修一下代码,或许能提高反编译的质量和准确率。 在去混淆到这种程度后,一个好办法是动静结合,再根据F5的部分残破的语句,可以大致还原整个算法(因为没有开编译器优化,因此可读性比较强)。 拿到算法后,可以选择写代码跑出结果,也可以上网进行信息收集。 我们不难得知,前人已经对该问题进行过研究了,人们把它叫做科斯塔斯阵列(Costas Arrays)。 参考链接:http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.137.8983&rep=rep1&type=pdf 已经有许多高效的算法被前人发明,我们通过模仿与借鉴这些代码就能求出最终的结果。 最后,将求出的结果转化成42个整数,以四十二进制的方式转换后即是最终flag。


赛题解析


本赛题解析由看雪论坛 KuCha128 给出:




0x00 简单分析




初步观察


题目是windows64位应用程序 观察PE文件的Section,发现有8个节,最后一个节.cop有可执行权限,说明应该有加壳或者其他处理。
 载入调试器看看EntryPoint代码。


好家伙!往EntryPoint上下翻翻都是清一色的代码块。 简单单步跟踪了一下,总结混淆方案共有4种。

混淆方案1:jmp指令简单变形

push rax push rax pushfq call $+5 pop rax xor rax,D1E15 mov qword ptr ss:[rsp+10],rax popfq pop rax ret


混淆方案2:jmp指令复杂变形

push raxpush raxpushfqpush raxpush raxpushfqcall $+5 pop raxxor rax, 0xE4EB3mov qword ptr ss:[rsp+0x10], raxpopfqpop raxpop raxadd rax, 0x8242Emov qword ptr ss:[rsp+0x10], raxpopfqpop raxret


混淆方案3:垃圾指令push reg/pop reg

push rdxpush rdxpop rdxpop rdxpush rcxpush rdxpop rdxpush rbxpop rbxpop rcx


混淆方案4:call指令变形

push raxpush raxpushfqpush raxpush raxpushfqcall $+5 pop raxxor rax, 0xDC85Fmov qword ptr ss:[rsp+0x10], raxpopfqpop raxpop raxadd rax, 0x51438mov qword ptr ss:[rsp+0x10], raxpopfqpop raxjmp 0x00000001400979D6


整理思路


整个文件大小将近1M了,可见混淆数量之多。
如果想靠肉眼顶着混淆看代码,属实不太现实。

看来必须要先写代码对PE文件的混淆做优化,再来盘程序逻辑了。



0x01 优化题目的混淆




准备工作


针对题目里的4种混淆方案,需要给出对应的去混淆方法。
不需要优化得很彻底,只需要能不太费力地使用肉眼看代码逻辑即可。 干到这里留了个心眼。
既然咱们需要patch题目文件优化混淆,题目会不会有CRC之类的校验代码段,如果有的话还得先要patch代码校验。
先简单手动修改一处混淆(随便找了个混淆方案1的地方改成jmp指令),保存文件,运行。
yes,运行是没问题,看来可以放心patch题目文件了。 要patch文件,首先需要把文件读入到内存中来,Section展开不展开都行。
这里需要注意的是,题目的ImageBase是默认0x140000000,申请的地址要满足后面几位也最好都是0,要不然混淆方案中的xor计算会出错。
static uintptr_t g_nFileSize = 0;static LPBYTE g_lpFileBuffer = 0; void ReadPEFile(){ HANDLE hFile = NULL; HANDLE hMapFile = NULL; LPBYTE lpFileBuffer = NULL; hFile = CreateFileA("D:\\Projects\\KCTF2021Spring\\CrackNote\\6\\KCTF.exe", // open MYFILE.TXT GENERIC_READ, // open for reading FILE_SHARE_READ, // share for reading NULL, // no security OPEN_EXISTING, // existing file only FILE_ATTRIBUTE_NORMAL, // normal file NULL); if (hFile == INVALID_HANDLE_VALUE) { std::cout << "Could not create file." << std::endl; goto EXIT_PROC; } g_nFileSize = GetFileSize(hFile, NULL); if (g_nFileSize == INVALID_FILE_SIZE) { std::cout << "GetFileSize error." << std::endl; goto EXIT_PROC; } hMapFile = CreateFileMappingA(hFile, NULL, PAGE_READONLY, 0, 0, NULL); if (hMapFile == NULL) { std::cout << "Could not create file-mapping object." << std::endl; goto EXIT_PROC; } lpFileBuffer = (LPBYTE)MapViewOfFile(hMapFile, // Handle to mapping object. FILE_MAP_READ, // Read/write permission 0, // Max. object size. 0, // Size of hFile. 0); // Map entire file. if (lpFileBuffer == NULL) { std::cout << "MapViewOfFile error." << std::endl; goto EXIT_PROC; } g_lpFileBuffer = new BYTE[g_nFileSize + 0x100000000]; g_lpFileBuffer = (LPBYTE)(((QWORD)g_lpFileBuffer & 0xFFFFFFFF00000000) + 0x100000000); if (g_lpFileBuffer == NULL) { std::cout << "Alloc error." << std::endl; goto EXIT_PROC; } memcpy(g_lpFileBuffer, lpFileBuffer, g_nFileSize); EXIT_PROC: if (lpFileBuffer != NULL) { UnmapViewOfFile(lpFileBuffer); lpFileBuffer = NULL; } if (hMapFile != NULL) { CloseHandle(hMapFile); hMapFile = NULL; } if (hFile != INVALID_HANDLE_VALUE) { CloseHandle(hFile); hFile = INVALID_HANDLE_VALUE; }}


去混淆方案1


混淆方案1等价于jmp指令。
jmp到的地址等于call $+5下一行的地址异或上后面的异或值常量。
这种混淆方案在反汇编代码中随便拉都是,看来可以先进行一波特征搜索patch掉这些"肉眼可见"的jmp变异。
这里需要注意的是, .text节和.cop节都是代码段,两个代码段之间会有很多来回的跳转。没有展开Section的话,需要注意FA和VA的转换。
void AntiObfuscation1(){ LPBYTE lpStart = 0; LPBYTE lpEnd = 0; DWORD dwCount = 0; //.text节 lpStart = g_lpFileBuffer+0x400; lpEnd = lpStart + 0x3000; dwCount = 0; while (true) { //50509CE800000000584835????????48894424109D58C3 LPBYTE lpResult = (LPBYTE)FindHexBytes(lpStart, lpEnd, "50509CE800000000584835????????48894424109D58C3"); if (lpResult == (LPVOID)-1) { break; } else { dwCount++; lpStart = lpResult + 1; QWORD dwFindFA = (QWORD)lpResult; QWORD dwFindVA = dwFindFA - 0x400 + 0x1000; QWORD dwCallNextFA = (QWORD)lpResult + 8; QWORD dwCallNextVA = dwCallNextFA - 0x400 + 0x1000 ;// 修复内存地址 QWORD dwXor = Mem_RDword(lpResult + 11); QWORD dwNextAddrVA = dwXor ^ dwCallNextVA; QWORD dwNextAddrFA = 0; BYTE aryNop[25] = { 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, }; //混淆方案1的opcode size 固定是23 Mem_WBytes2((LPVOID)dwFindFA, aryNop, 23); BYTE bHook[] = { 0xE9, 00, 00, 00, 00 }; Mem_WDword(bHook + 1, (DWORD)(dwNextAddrVA - dwFindVA - 5)); Mem_WBytes2((LPVOID)dwFindFA, bHook, 5); } } D("patch .text %d addr", dwCount); //.cop节 lpStart = g_lpFileBuffer + 0x6200; lpEnd = lpStart + 0xF0000; dwCount = 0; while (true) { //50509CE800000000584835????????48894424109D58C3 LPBYTE lpResult = (LPBYTE)FindHexBytes(lpStart, lpEnd, "50509CE800000000584835????????48894424109D58C3"); if (lpResult == (LPVOID)-1) { break; } else { dwCount++; lpStart = lpResult + 1; QWORD dwFindFA = (QWORD)lpResult; QWORD dwFindVA = dwFindFA - 0x6200 + 0xB000; QWORD dwCallNextFA = (QWORD)lpResult + 8; QWORD dwCallNextVA = dwCallNextFA - 0x6200 + 0xB000;// 修复内存地址 QWORD dwXor = Mem_RDword(lpResult + 11); QWORD dwNextAddrVA = dwXor ^ dwCallNextVA; QWORD dwNextAddrFA = 0; BYTE aryNop[25] = { 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, }; //混淆方案1的opcode size 固定是23 Mem_WBytes2((LPVOID)dwFindFA, aryNop, 23); BYTE bHook[] = { 0xE9, 00, 00, 00, 00 }; Mem_WDword(bHook + 1, (DWORD)(dwNextAddrVA - dwFindVA - 5)); Mem_WBytes2((LPVOID)dwFindFA, bHook, 5); } } D("patch .cop %d addr", dwCount);}


去混淆方案2


混淆方案2等价于jmp指令。
但是混淆方案2的代码在内存中并不是连续的,不好直接搜索特征来去除。
这里想到个办法,使用x64dbg的TraceInto/TraceOver功能先跑出代码来,再针对输出的log文件进行文本分析得到混淆方案2的地址。 x64dbg的TraceInto/TraceOver功能,要输出到log的内容填上0x{p:cip} {i:cip},选择好输出log文件的路径,设置步数500000(只能多不能少,至少要跑完输出错误提示吧),不用设置停止条件,即可开始trace。
 trace出来的文件大概是这样。 
一眼望去,代码里全是"去混淆方案1"中patch的jmp和成对出现的垃圾指令"push reg"/"pop reg"。
写个python脚本优化掉(方法见"去混淆方案3")。
去掉jmp和push reg/popreg的log文本文件中,可以见到混淆方案2的代码已经连续出现了。 这里使用notepad++的插件python script写个脚本,输出针对混淆方案2优化需要用到的几个重要参数(起始地址,callnext返回地址,异或运算常量,加法运算常量)。
a = editor.getText()lst=a.split("\n") lstdel=[] for index in range(len(lst)-18):#for index in range(20): i1=lst[index][-8:] i2=lst[index+1][-8:] i3=lst[index+2][-6:] i17=lst[index+17][-3:] #print(i1,i2,i3,i17) if(i1=="push rax" and i2=="push rax" and i3=="pushfq" and i17=="ret"): eva=lst[index][:18] cnva=lst[index+7][:18] xi=lst[index+8].rfind(" "); x=lst[index+8][xi:] ai=lst[index+13].rfind(" "); a=lst[index+13][ai:] print(eva, cnva, x, a) print("finish.")
输出的地址特别多,整理填入C代码中。
LPCSTR g_aryFix[] = {"0x00000001400B5E68", "0x000000014007FBD8", "0x6665", "0x36F74","0x000000014005AF49", "0x000000014002BB97", "0x28376", "0xCFCD7","0x00000001400E05AD", "0x0000000140040B81", "0xD7FA9", "0xFFFFFFFFFFFE178B","0x000000014000151A", "0x000000014008F26F", "0xDB257", "0x78C45","0x0000000140029C72", "0x000000014008F92C", "0xEB504", "0x328B9","0x000000014001E0A6", "0x00000001400F1BA1", "0x6545B", "0x28976","0x0000000140052153", "0x0000000140012134", "0x485D5", "0xFFFFFFFFFFFF07DE","0x0000000140083D5A", "0x000000014000F6CA", "0x43749", "0x46ACE","0x000000014002C47E", "0x00000001400C2EFA", "0xA5AD1", "0x89CB0","0x00000001400E7AEC", "0x00000001400370EC", "0xFEA16", "0xFFFFFFFFFFFD4BAC","0x00000001400C0C09", "0x00000001400F44A9", "0x2F746", "0xFFFFFFFFFFF323E7","0x000000014001533A", "0x0000000140020B70", "0x15CD7", "0x5E0A2","0x00000001400572A6", "0x0000000140059016", "0xFBD1", "0xFFFFFFFFFFFDF0F0","0x0000000140022F8A", "0x00000001400767A4", "0x4BE4C", "0x88F4",//... ...};
修复混淆方案2的代码(注意FA和RVA的转换):
void AntiObfuscation2(){ for (int i = 0; i < sizeof(g_aryFix) / sizeof(g_aryFix[0]) / 4 ;i++) { QWORD dwFindRVA = strtoull(g_aryFix[i * 4 ], 0, 16) - 0x140000000; QWORD dwFindFA = 0; if (dwFindRVA < 0x3400) { dwFindFA = (QWORD)g_lpFileBuffer + dwFindRVA - 0x1000 + 0x400; } else { dwFindFA = (QWORD)g_lpFileBuffer + dwFindRVA - 0xB000 + 0x6200; } QWORD dwCallNextRVA= strtoull(g_aryFix[i * 4+1], 0, 16) - 0x140000000; QWORD dwXor = strtoull(g_aryFix[i * 4 + 2], 0, 16); QWORD dwAdd = strtoull(g_aryFix[i * 4 + 3], 0, 16); QWORD dwNextAddrRVA = (dwXor ^ dwCallNextRVA) + dwAdd; BYTE bHook[] = { 0xE9, 00, 00, 00, 00 }; Mem_WDword(bHook + 1, (DWORD)(dwNextAddrRVA - dwFindRVA - 5)); Mem_WBytes2((LPVOID)dwFindFA, bHook, 5); }}


去混淆方案3


混淆方案3是垃圾指令,成对的push reg和pop reg。
写python脚本在log文本中搜索几次,即可清理干净。
这里内存中就不patch了,不是很影响看代码。
对python脚本输出的文件可以进行"去混淆方案2"和"去混淆方案4"操作。
a = editor.getText() lst=a.split("\n")print(len(lst)); lstdel=[] for index in range(len(lst)): if(lst[index][-22:-19] == "jmp"): lstdel.append(index) lstdel.reverse()print(len(lstdel))for index in range(len(lstdel)): del lst[lstdel[index]] for fxcktime in range(10): lstdel=[] for index in range(len(lst)-1): i1=lst[index][-8:-4] i2=lst[index+1][-7:-4] reg1=lst[index][-3:] reg2=lst[index+1][-3:] if(i1=="push" and i2=="pop" and reg1==reg2): #print(i1,reg1,i2,reg2) lstdel.append(index) lstdel.append(index+1) lstdel.reverse() for index in range(len(lstdel)): del lst[lstdel[index]] fo=open("D:\\Projects\\KCTF2021Spring\\CrackNote\\6\\crack\\crack\\pyoutput.txt","w")for index in range(len(lst)): fo.write(lst[index]+"\n")fo.close() print("finish.");


去混淆方案4


混淆方案4等价于call指令。
混淆方案4和混淆方案2的差别,就是最后一行一个是ret,一个是jmp。
push addr+ret = jmp addr
push addr1+jmp addr2= call addr2 (addr1是call的返回地址)
 在对trace的log进行完"去混淆指令3"和"去混淆指令2"操作后,混淆方案4的代码就会"浮出水面",在文本中连续。
继续写python脚本提取输出要处理的信息。
a = editor.getText()lst=a.split("\n") lstdel=[] for index in range(len(lst)-18): i1=lst[index][-8:] i2=lst[index+1][-8:] i3=lst[index+2][-6:] i17=lst[index+16][-7:] #print(i1,i2,i3,i17) if(i1=="push rax" and i2=="push rax" and i3=="pushfq" and i17=="pop rax"): eva=lst[index][:18] cnva=lst[index+7][:18] callva=lst[index+17][:18] xi=lst[index+8].rfind(" "); x=lst[index+8][xi:] ai=lst[index+13].rfind(" "); a=lst[index+13][ai:] print(eva, cnva, x, a, callva) print("finish.")
输出的数据填入C代码中,进行修复。
LPCSTR g_aryFixC[] = {"0x000000014007A4C4", "0x0000000140089E51", "0xDC85F", "0x51438", "0x00000001400979D8","0x00000001400ADE90", "0x00000001400D4AEB", "0x4E2ED", "0xFFFFFFFFFFFD199D", "0x0000000140060882","0x00000001400C4678", "0x0000000140020DE5", "0xD2FE3", "0xFFFFFFFFFFFBE04E", "0x000000014005CC6E","0x00000001400C673D", "0x00000001400E7B10", "0xEB97C", "0x6DDA4", "0x000000014003F7D0","0x000000014003AF3E", "0x00000001400DDF75", "0x9704D", "0xFFFFFFFFFFFF5B08", "0x000000014003F7D0","0x00000001400839E3", "0x000000014007BECA", "0x36372", "0xA777F", "0x00000001400F90F3","0x00000001400543F0", "0x00000001400A164B", "0xAF7C5", "0x5CDEC", "0x0000000140078E09","0x00000001400998CB", "0x00000001400B928A", "0x2C92F", "0xE4AC", "0x00000001400D031B","0x000000014007C0AC", "0x000000014009B414", "0xC2AD8", "0xFFFFFFFFFFFC7598", "0x00000001400D42F1","0x000000014008FBE2", "0x0000000140047E12", "0x56408", "0x65F02", "0x00000001400728A0","0x000000014006B5D5", "0x00000001400194CA", "0x78E10", "0x3996", "0x0000000140078E09","0x0000000140077920", "0x00000001400A2374", "0x4FE21", "0xFFFFFFFFFFFCEA1A", "0x0000000140039068","0x00000001400943F5", "0x000000014008595F", "0xF3ADF", "0xFFFFFFFFFFFA3AAE", "0x00000001400DFCB3","0x000000014003768E", "0x000000014005087F", "0x45B11", "0xAB445", "0x000000014002650B",//... ...};
优化混淆方案4的方法:
1. 起始地址jmp到返回地址-5


2. 返回地址-5的地址,填入call指令
void AntiObfuscation3(){ for (int i = 0; i < sizeof(g_aryFixC) / sizeof(g_aryFixC[0]) / 5; i++) { QWORD dwFindRVA = strtoull(g_aryFixC[i * 5], 0, 16) - 0x140000000; QWORD dwFindFA = 0; if (dwFindRVA < 0x3400) { dwFindFA = (QWORD)g_lpFileBuffer + dwFindRVA - 0x1000 + 0x400; } else { dwFindFA = (QWORD)g_lpFileBuffer + dwFindRVA - 0xB000 + 0x6200; } QWORD dwCallNextRVA = strtoull(g_aryFixC[i * 5 + 1], 0, 16) - 0x140000000; QWORD dwXor = strtoull(g_aryFixC[i * 5 + 2], 0, 16); QWORD dwAdd = strtoull(g_aryFixC[i * 5 + 3], 0, 16); QWORD dwReturnRVA = (dwXor ^ dwCallNextRVA) + dwAdd; QWORD dwCallRVA= strtoull(g_aryFixC[i * 5+4], 0, 16) - 0x140000000; QWORD dwHookRVA = dwReturnRVA - 5 ; QWORD dwHookFA = 0; if (dwHookRVA < 0x3400) { dwHookFA = (QWORD)g_lpFileBuffer + dwHookRVA - 0x1000 + 0x400; } else { dwHookFA = (QWORD)g_lpFileBuffer + dwHookRVA - 0xB000 + 0x6200; } //BYTE nop[] = { 0x90,0x90,0x90,0x90,0x90 }; //if (memcmp((LPBYTE)dwHookFA, nop, 5) != 0) //{ // printf("5 nops is not found.\r\n"); //} BYTE bHook[] = { 0xE9, 00, 00, 00, 00 }; Mem_WDword(bHook + 1, (DWORD)(dwHookRVA - dwFindRVA - 5)); Mem_WBytes2((LPVOID)dwFindFA, bHook, 5); BYTE bHook2[] = { 0xE8, 00, 00, 00, 00 }; Mem_WDword(bHook2 + 1, (DWORD)(dwCallRVA - dwHookRVA - 5)); Mem_WBytes2((LPVOID)dwHookFA, bHook2, 5); }}



0x02 分析程序的验证逻辑




去混淆过后的代码


经过优化代码混淆后,看一下EntryPoint处代码。
0x0000000140071DE0 sub rsp, 0x280x00000001400A6A41 call 0x00000001400979D80x000000014000B2EA add rsp, 0x280x0000000140066D1D jmp 0x00000001400F4594 0x00000001400F4596 mov qword ptr ss:[rsp+0x8], rbx0x00000001400574EC mov qword ptr ss:[rsp+0x10], rsi0x000000014003AE64 push rdi0x00000001400339F9 sub rsp, 0x300x00000001400B5EDB mov ecx, 0x10x000000014006C19E call 0x00000001400608820x0000000140052C16 test al, al0x000000014002C7AC je 0x000000014002C7C50x00000001400FA123 xor sil, sil0x00000001400503B8 mov byte ptr ss:[rsp+0x20], sil0x00000001400F5532 call 0x00000001400F90F30x000000014005EB9A mov bl, al0x000000014003B118 mov ecx, dword ptr ds:[0x00000001400066A0]0x0000000140082181 cmp ecx, 0x10x00000001400F4B13 je 0x00000001400F4B2C0x0000000140044567 test ecx, ecx0x000000014000E80D jne 0x000000014000E8260x000000014001938F mov dword ptr ds:[0x00000001400066A0], 0x10x00000001400768A6 lea rdx, ds:[0x0000000140004658]0x00000001400D8E77 lea rcx, ds:[0x0000000140004640]0x00000001400A404C call 0x00000001400D031B0x000000014001A610 test eax, eax0x00000001400BF926 je 0x00000001400BF93F0x00000001400BBCE9 lea rdx, ds:[0x0000000140004638]0x000000014003E227 lea rcx, ds:[0x0000000140004620]0x000000014002145F call 0x00000001400D42F10x000000014007CF86 mov dword ptr ds:[0x00000001400066A0], 0x20x000000014004C6F2 mov cl, bl0x0000000140077917 call 0x00000001400728A00x00000001400BC76A call 0x00000001400390680x0000000140046F51 mov rbx, rax0x00000001400E073E cmp qword ptr ds:[rax], 0x00x00000001400A2DD5 je 0x00000001400A2DEE0x0000000140019E29 call 0x00000001400DFCB30x0000000140019E30 mov rbx, rax0x00000001400C7DA6 cmp qword ptr ds:[rax], 0x00x000000014007A883 je 0x000000014007A89C0x00000001400C07AE call 0x000000014002650B ;_get_initial_narrow_environment0x000000014008E432 mov rdi, rax0x0000000140041D1A call 0x00000001400EED90 ;__p___argv0x0000000140041D23 mov rbx, qword ptr ds:[rax]0x0000000140088748 call 0x0000000140068129 ;__p___argc0x0000000140092400 mov r8, rdi0x000000014003D3AC mov rdx, rbx0x0000000140059887 mov ecx, dword ptr ds:[rax]0x00000001400A313B call 0x0000000140083814 ;main
这代码看着就眼熟多了,一股浓浓的VS201X编译器的味道。 main函数给定位到了:0x0000000140083814
下次trace就可以从main函数开始。 暂时想不到什么很好的办法能把代码全部按顺序修复好让ida能F5,不过这反汇编代码已经看起来非常舒适了。 又多再看了几眼,程序编译的时候没有开优化,反汇编代码看起来又2更亲切了。 多亏笔者在武汉科锐培训三阶段的时候,练就了看汇编代码还原C代码的"神功",就当成是培训时间一次正常的作业吧。

main函数里程序的验证逻辑


首先是输出字符串到控制台和输入flag。
输入输出使用的std::cout和std::cin。
输入之后首先判断了输入字符串的长度(0x54=84)。
0x0000000140083333 cmp rax, 0x540x0000000140035698 je 0x00000001400356B1 ;输出错误提示0x0000000140048E00 lea rcx, ds:[0x000000014000449C]0x0000000140089A5C call 0x00000001400CD482 0x00000001400356B1 ....
接着为一块大小为0x150的局部变量数组初始化数值为0
0x0000000140064B92 xor edx, edx0x0000000140013BE4 lea rax, ss:[rsp+0xEC0]0x000000014002469D mov rcx, rax0x000000014004DA5D mov r8d, 0x1500x000000014007C4C8 call 0x00000001400C2EA1 ;memset
观察对改数组的访问得到数组成员size为8字节,算出总共42个成员,数组定义如下:
QWORD ary[42] = {0};
循环往数组内写入数据。
写入数值=取一个字符转数字*0x2A+再取一个字符转数字。
;循环遍历输入的flag0x00000001400462B9 mov qword ptr ss:[rsp+0xA8], 0x0 ;循环变量0x0000000140054381 cmp qword ptr ss:[rsp+0xA8], 0x2A ;循环次数=0x2A0x000000014001603F jge 0x00000001400160580x0000000140036738 lea rax, ss:[rsp+0x1010]0x00000001400B433E mov rcx, qword ptr ss:[rsp+0xA8]0x00000001400F7366 shl rcx, 0x10x00000001400F9958 add rax, rcx0x00000001400F5555 mov rcx, rax0x0000000140014AC7 call 0x0000000140018FA3 ;计算数值0x0000000140014ACC mov rcx, qword ptr ss:[rsp+0xA8]0x00000001400787FE mov qword ptr ss:[rsp+rcx*8+0xEC0], rax ;写入数组0x00000001400AFE42 cmp qword ptr ss:[rsp+0xA8], 0x00x0000000140022C16 je 0x0000000140022C2F0x00000001400297BA mov rax, qword ptr ss:[rsp+0xA8]0x00000001400BCBD3 add rax, 0x1 ;循环变量+=10x000000014007F965 mov qword ptr ss:[rsp+0xA8], rax0x0000000140054381 cmp qword ptr ss:[rsp+0xA8], 0x2A0x000000014001603F jge 0x0000000140016058;...进入下一次循环 ;计算数值函数0x000000014005828F sub rsp, 0x380x00000001400AB750 mov qword ptr ss:[rsp+0x30], rcx0x00000001400466C5 mov rax, qword ptr ss:[rsp+0x30]0x000000014003423A mov cl, byte ptr ds:[rax]0x0000000140048352 call 0x00000001400706B9 ;字符转数字0x00000001400BA991 imul rax, rax, 0x2A0x00000001400EA00D mov rdx, qword ptr ss:[rsp+0x30]0x0000000140039BC9 mov cl, byte ptr ds:[rdx+0x1]0x000000014005C2D0 mov qword ptr ss:[rsp+0x28], rax0x000000014008807A call 0x0000000140087B21 ;字符转数字0x0000000140037CF1 mov rdx, qword ptr ss:[rsp+0x28]0x00000001400A7DF8 add rdx, rax0x00000001400D4395 mov rax, rdx0x0000000140057C71 add rsp, 0x380x00000001400DA66E ret ;字符转数字函数(部分)0x00000001400D5E04 sub rsp, 0x100x00000001400731D7 mov byte ptr ss:[rsp+0x7], cl0x00000001400882CA movsx eax, byte ptr ss:[rsp+0x7]0x00000001400938D7 cmp eax, 0x300x000000014009A8BF jl 0x000000014009A8D80x00000001400B54F4 movsx eax, byte ptr ss:[rsp+0x7]0x000000014001A372 cmp eax, 0x39 ;'9'0x00000001400794C2 jg 0x00000001400794DB0x0000000140043279 movsx rax, byte ptr ss:[rsp+0x7]0x0000000140069CAB sub rax, 0x30 ;'0'0x000000014002E028 mov qword ptr ss:[rsp+0x8], rax0x000000014008BBCC mov rax, qword ptr ss:[rsp+0x8]0x00000001400831F7 add rsp, 0x100x000000014002974F ret
仔细分析字符转数字函数,得出输入总共有42个字符,分别是:
0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ+-*/%=
 下标就是这个字符转换出来的数值: 接着循环判断数组QWORD ary[42]中以从小到大顺序排序。
0x0000000140014ACC mov rcx, qword ptr ss:[rsp+0xA8]0x00000001400787FE mov qword ptr ss:[rsp+rcx*8+0xEC0], rax0x00000001400AFE42 cmp qword ptr ss:[rsp+0xA8], 0x00x0000000140022C16 je 0x0000000140022C2F0x00000001400CD021 mov rax, qword ptr ss:[rsp+0xA8]0x00000001400EA72C mov rax, qword ptr ss:[rsp+rax*8+0xEC0]0x00000001400ABE1C mov rcx, qword ptr ss:[rsp+0xA8]0x00000001400E81E3 sub rcx, 0x10x00000001400906DB cmp rax, qword ptr ss:[rsp+rcx*8+0xEC0]0x00000001400A9B93 jg 0x00000001400A9BAC0x00000001400297BA mov rax, qword ptr ss:[rsp+0xA8]0x00000001400BCBD3 add rax, 0x10x000000014007F965 mov qword ptr ss:[rsp+0xA8], rax0x0000000140054381 cmp qword ptr ss:[rsp+0xA8], 0x2A0x000000014001603F jge 0x0000000140016058
这里说明flag偶数下标的数值是限制死的。
只能是"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ+-*/%="依次排序。 接着一个check,循环判断了输入的偶数位和奇数位字符都不一样。
;初始化两个数组char ary[42],分别记录偶数位和奇数位每个字符是否出现0x0000000140001A0A xor eax, eax0x00000001400CCBF8 lea rcx, ss:[rsp+0xE90]0x000000014004662E mov edx, eax0x000000014004A3B8 mov r8d, 0x2A0x000000014006EA24 mov qword ptr ss:[rsp+0x48], r80x0000000140098B05 mov dword ptr ss:[rsp+0x44], eax ;call memset0x00000001400C9BCF jmp qword ptr ds:[0x0000000140004BD8] 0x00000001400930C5 lea rcx, ss:[rsp+0xE60]0x00000001400EE6C6 mov edx, dword ptr ss:[rsp+0x44]0x00000001400450F9 mov r8, qword ptr ss:[rsp+0x48] ;call memset0x00000001400C9BCF jmp qword ptr ds:[0x0000000140004BD8] ;循环check0x000000014007D9D5 mov qword ptr ss:[rsp+0xA0], 0x0 ;循环变量0x0000000140002310 cmp qword ptr ss:[rsp+0xA0], 0x2A ;循环次数=2A0x0000000140003980 jge 0x0000000140003999;对0x2A一个求模一个整除, 等于是拿出了偶数位和奇数位数值0x0000000140001F98 mov rax, qword ptr ss:[rsp+0xA0]0x000000014002D5AC mov rax, qword ptr ss:[rsp+rax*8+0xEC0]0x0000000140046C25 cqo0x0000000140073E00 mov ecx, 0x2A0x000000014009EA4F idiv rcx0x000000014006EC35 mov qword ptr ss:[rsp+0x98], rdx0x0000000140060863 mov rdx, qword ptr ss:[rsp+0xA0]0x0000000140063300 mov rdx, qword ptr ss:[rsp+rdx*8+0xEC0]0x0000000140017083 mov rax, rdx0x00000001400C5CFF cqo0x00000001400BCF6C idiv rcx0x000000014008CCE3 mov qword ptr ss:[rsp+0x90], rax0x00000001400BF3EF mov rax, qword ptr ss:[rsp+0x98];判断这个数值是否已经存在0x000000014004B8F8 test byte ptr ss:[rsp+rax*1+0xE90], 0x10x00000001400ECE6A jne 0x00000001400ECE830x000000014005224C mov rax, qword ptr ss:[rsp+0x90]0x0000000140015F89 test byte ptr ss:[rsp+rax*1+0xE60], 0x10x000000014006633F je 0x0000000140066358;填入两个数组,记录当前已出现的数值0x0000000140027ACE mov rax, qword ptr ss:[rsp+0x98]0x00000001400E4C50 mov byte ptr ss:[rsp+rax*1+0xE90], 0x10x00000001400AC622 mov rax, qword ptr ss:[rsp+0x90]0x00000001400B5248 mov byte ptr ss:[rsp+rax*1+0xE60], 0x10x0000000140037CD0 mov rax, qword ptr ss:[rsp+0xA0]0x0000000140026ABB add rax, 0x10x00000001400258E6 mov qword ptr ss:[rsp+0xA0], rax0x0000000140002310 cmp qword ptr ss:[rsp+0xA0], 0x2A0x0000000140003980 jge 0x0000000140003999;进入下一次循环
这里可以得到信息,奇数位的中每个字符都只出现一次。 接着下一个check,嵌套的循环。
大概逻辑是,相邻的偶数位和奇数位为一组,对于每两组数据差得到的一个坐标都不能相同(这里形容得不太准确,没有太理解,直接把反汇编代码翻译成C代码写个check函数即可)。
;初始化数组, size=1743;@@@这里发现个问题, 后面给这个数组填数值的时候越界了;@@@在这里纠结了很久, 照着代码抄下, 假装没看到数组访问越界就过了0x000000014006B00F xor edx, edx0x0000000140088714 lea rax, ss:[rsp+0xC0]0x00000001400CA071 mov rcx, rax0x0000000140062801 mov r8d, 0xD9E ;1743 call memset0x00000001400C9BCF jmp qword ptr ds:[0x0000000140004BD8] ;嵌套的循环开始, 两两比较;for (int i = 0; i < 42; i++)0x00000001400C86F5 mov qword ptr ss:[rsp+0x88], 0x0 0x00000001400D320E cmp qword ptr ss:[rsp+0x88], 0x2A0x0000000140056B2F jge 0x0000000140056B48;for (int j = i+1; j < 42;j++)0x00000001400CBE10 mov rax, qword ptr ss:[rsp+0x88] 0x0000000140052E9D add rax, 0x10x0000000140078F7F mov qword ptr ss:[rsp+0x80], rax0x00000001400286DB cmp qword ptr ss:[rsp+0x80], 0x2A0x000000014000D91A jge 0x000000014000D933 0x00000001400F755C mov rax, qword ptr ss:[rsp+0x88] 0x0000000140098656 mov rax, qword ptr ss:[rsp+rax*8+0xEC0] 0x0000000140001584 cqo0x00000001400A4ADC mov ecx, 0x2A0x000000014001F9E4 idiv rcx0x000000014009D9EA mov r8, qword ptr ss:[rsp+0x80]0x00000001400EDA1E mov r8, qword ptr ss:[rsp+r8*8+0xEC0]0x000000014000EA19 mov rax, r80x00000001400A04C3 mov qword ptr ss:[rsp+0x38], rdx ;rsp_38 = ary[i]%420x0000000140037898 cqo0x000000014008A550 idiv rcx0x00000001400399F2 mov r8, qword ptr ss:[rsp+0x38] ;r8 = ary[j]%420x00000001400D4106 sub r8, rdx ;r8 = ary[j]%42- ary[i]%420x00000001400901F7 mov qword ptr ss:[rsp+0x78], r80x000000014009DAAD mov rdx, qword ptr ss:[rsp+0x88]0x000000014000E4E3 mov rdx, qword ptr ss:[rsp+rdx*8+0xEC0]0x00000001400EE409 mov rax, rdx0x00000001400C5EE9 cqo0x0000000140020832 idiv rcx0x000000014008C074 mov r8, qword ptr ss:[rsp+0x80]0x000000014006079F mov r8, qword ptr ss:[rsp+r8*8+0xEC0]0x0000000140013395 mov qword ptr ss:[rsp+0x30], rax0x00000001400C5CE1 mov rax, r80x000000014002FFF8 cqo0x000000014007539E idiv rcx0x000000014006BF8A mov rcx, qword ptr ss:[rsp+0x30]0x00000001400DB4A6 sub rcx, rax ;rcx = ary[j]/42- ary[i]/420x00000001400E127D mov qword ptr ss:[rsp+0x70], rcx ;if(ary[j]%42- ary[i]%42 > 0)0x0000000140073B88 cmp qword ptr ss:[rsp+0x78], 0x0 0x000000014006437E jge 0x0000000140064397 ; 负数 则 取反 0x00000001400BFF7E xor eax, eax0x00000001400EACB4 mov ecx, eax0x0000000140080A4A mov rdx, rcx ;0x00000001400A7967 sub rdx, qword ptr ss:[rsp+0x78] 0x00000001400517AC mov qword ptr ss:[rsp+0x78], rdx0x000000014000B3C6 sub rcx, qword ptr ss:[rsp+0x70]0x000000014008DA3A mov qword ptr ss:[rsp+0x70], rcx 0x0000000140064397: ;ary[j]/42 -ary[i]/42 +0x290x00000001400B368C mov rax, qword ptr ss:[rsp+0x70] 0x0000000140079E70 add rax, 0x29 0x00000001400DE855 mov qword ptr ss:[rsp+0x70], rax ;这里看出是个二维数组访问;说明上面初始化的数组定义为char ary[?][42]0x000000014007C0C8 imul rax, qword ptr ss:[rsp+0x70], 0x2A0x00000001400DC9B8 lea rcx, ss:[rsp+0xC0]0x0000000140038FE8 add rcx, rax0x0000000140067202 mov rax, qword ptr ss:[rsp+0x78]; 判断不为10x000000014008B8FD test byte ptr ds:[rcx+rax*1], 0x10x0000000140063B11 je 0x0000000140063B2A ; 填10x00000001400F9BFC imul rax, qword ptr ss:[rsp+0x70], 0x2A 0x000000014007F148 lea rcx, ss:[rsp+0xC0]0x00000001400EB582 add rcx, rax0x0000000140065E6F mov rax, qword ptr ss:[rsp+0x78]0x00000001400F535F mov byte ptr ds:[rcx+rax*1], 0x1 ; j++0x0000000140013FC1 mov rax, qword ptr ss:[rsp+0x80] 0x0000000140073E8D add rax, 0x10x00000001400B9738 mov qword ptr ss:[rsp+0x80], rax 0x00000001400286DB cmp qword ptr ss:[rsp+0x80], 0x2A0x000000014000D91A jge 0x000000014000D933;进入下一次循环
这里check逻辑虽然看得不是太懂,但是突然产生个脑洞。
有点像n皇后问题,使用回溯法写代码可以解决。
但是看这规模,跑出flag来估计也要个很长时间,不太现实。

继续看后面有没有check可以缩小穷举范围。 一看果然有发现:
;strncmp0x0000000140039540 lea rcx, ss:[rsp+0x1010]0x00000001400CE591 lea rdx, ds:[0x0000000140004521]0x000000014004CACF mov r8d, 0x1C0x000000014004FED9 call qword ptr ds:[0x0000000140004C98]
[rsp+0x1010]是输入的flag。
[0x0000000140004521]是"02152S3X4Z5Q6C7T819/ADB%C*DL"。 也就是说,flag的前面28个字符已经给定。
范围已经缩到最小,可以写代码进行穷举了。

0x03 回溯法穷举flag




百度抄下个n皇后回溯法求解的代码,修改下check函数和输入输出。
int n = 0; #define BORDER 42char board[BORDER] = { 0 }; char tb[200][42] = { 0 }; char x[2000] = { 0 };char y[2000] = { 0 }; bool check(char row, char col){ for (char i = 0; i < row; i++) { if (board[i] == col) { return false; } } short sum = 0; for (char i = 0; i < row; i++) { for (char j = i+1; j < row; j++) { int tmp1 = j - i; int tmp2 = board[j] - board[i]; if (tmp2 < 0) { tmp1 = 0 - tmp1; tmp2 = 0 - tmp2; } tmp1 = tmp1 + 0x29; x[sum] = tmp1; y[sum] = tmp2; sum++; } } memset(tb, 0, sizeof(tb)); for (short j = 0; j < sum; j++) { tb[x[j]][y[j]] = 1; } for (char i = 0; i < row; i++) { //(i, board[i]) //(row, col) int tmp1 = row - i; int tmp2 = col - board[i]; if (tmp2 < 0) { tmp1 = 0 - tmp1; tmp2 = 0 - tmp2; } tmp1 = tmp1 + 0x29; if (tb[tmp1][tmp2] == 1) { return false; } } return true;} void fxck(char row){ if (row == BORDER) { //output n++; printf("%d: 02152S3X4Z5Q6C7T819/ADB%cC*DL", n, '%'); for (int i = 14; i < BORDER; i++) { printf("%c%c", getccc(i), getccc(board[i])); } printf("\r\n"); return; } for (char col = 0; col < BORDER; col++) { if (check(row, col)) { board[row] = col; fxck(row + 1); } }} int main(int argc, const char** argv, const char** envp){ //"02152S3X4Z5Q6C7T819/ADB%C*DL" LPCSTR yz = "25SXZQCT1/D%*L"; for(int i = 0; i< 14;i++) { auto idx= getidx(yz[i]); board[i] = idx; } fxck(14); printf("n = %d \r\n", n); return 0;}
运行出结果需要一段时间,一两分钟吧,不算太久。
Microsoft Windows [版本 10.0.19042.985](c) Microsoft Corporation。保留所有权利。 C:\Users\KuCha\Desktop\crack_login\x64\Release>crack_login.exe1: 02152S3X4Z5Q6C7T819/ADB%C*DLEIFUG3HRIHJ6K7L0MBNKOJPPQ=RNS+TEUOVWWGXYYMZ9+4-8*F/-%V=An = 1 C:\Users\KuCha\Desktop\crack_login\x64\Release>
02152S3X4Z5Q6C7T819/ADB%CDLEIFUG3HRIHJ6K7L0MBNKOJPPQ=RNS+TEUOVWWGXYYMZ9+4-8F/-%V=A



0x04 总结




题目主打还是代码流,很合笔者胃口。
题目算法验证部分最后一个循环check中,有数组越界访问,这个很坑,不按题目算法这样写代码得不到flag。
混淆方法太少,使得攻击者要恢复代码工作量不算太大。 附件文件:
crack_login.cpp是修复混淆和最后回溯穷举的代码
KCTF_patch8.exe是优化部分混淆过后的题目可执行文件

python script.rar是notepad++插件的python脚本



往期解析


1. 看雪·深信服 2021 KCTF 春季赛 | 第二题设计思路及解析

2. 看雪·深信服 2021 KCTF 春季赛 | 第三题设计思路及解析3. 看雪·深信服 2021 KCTF 春季赛 | 第四题设计思路及解析4. 看雪·深信服 2021 KCTF 春季赛 | 第五题设计思路及解析




主办方
看雪CTF(简称KCTF)是圈内知名度最高的技术竞技之一,从原CrackMe攻防大赛中发展而来,采取线上PK的方式,规则设置严格周全,题目涵盖Windows、Android、iOS、Pwn、智能设备、Web等众多领域。
看雪CTF比赛历史悠久、影响广泛。自2007年以来,看雪已经举办十多个比赛,与包括金山、360、腾讯、阿里等在内的各大公司共同合作举办赛事。比赛吸引了国内一大批安全人士的广泛关注,历年来CTF中人才辈出,汇聚了来自国内众多安全人才,高手对决,精彩异常,成为安全圈的一次比赛盛宴,突出了看雪论坛复合型人才多的优势,成为企业挑选人才的重要途径,在社会安全事业发展中产生了巨大的影响力。

合作伙伴

深信服科技股份有限公司成立于2000年,是一家专注于企业级安全、云计算及基础架构的产品和服务供应商,致力于让用户的IT更简单、更安全、更有价值。目前深信服在全球设有50余个分支机构,员工规模超过7000名。

比赛火热进行中!立即挑战!



- End -



公众号ID:ikanxue
官方微博:看雪安全
商务合作:wsc@kanxue.com




球分享

球点赞

球在看



戳“阅读原文”一起来充电吧!

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存